WNS-28 Fix WIRE-349 creator chain-kind validation - #601
Conversation
Change-Id: I697804f02a44b93e6485a0b4b91ef25427f9de77
Change-Id: Ifc7cd1309e33b02257218b904a19a5e8c73e16e2
heifner
left a comment
There was a problem hiding this comment.
Reviewed against the CertiK WNS-28 report. The fix is correct and complete for the reported issue.
What I checked beyond the diff itself:
The premise holds. sysio.msgch::dispatch_reserve_create binds ext.chain_code to the proven delivering outpost, then forwards rc.creator_addr.kind verbatim — so chain_code is proven and the creator kind is free-form payload, exactly as reported.
The invariant cannot drift. regchain is the only writer of a chain row's kind; activchain and setoutpost do not touch it, and there is no erase. A registered code's kind is therefore immutable, which is what makes resolving it independently in oncrtreserve and matchreserve sufficient rather than needing the kind pinned on the reserve row.
Consistent with existing precedent. sysio.uwrit::swapfromwire already enforces the same registry-kind equality for recipient_kind, so this brings oncrtreserve in line rather than introducing a new pattern.
The committed WASM carries the fix. Since CI deploys the checked-in artifact as-is, I checked the binary rather than the source: the new rejection string is present in the committed wasm and absent from the base. The ABI is untouched, consistent with the unchanged action signature.
The regression test is genuine. authex::recordlink does not validate key type against chain_kind, and bypubkey is keyed on the key hash with no kind component — so pre-fix the SVM-linked ED key would have resolved as linked and produced a PENDING row. The test fails without the change.
Reclaim interaction is sound. A mismatched create against a live PENDING row hits the existing-row guard; against a CANCELLED row it takes the no-double-refund branch. Neither clobbers a legitimate reserve.
One design question, not a blocker: the mismatch adds a new trigger for writing a CANCELLED tombstone and queueing a refund on a triple that a legitimate creator can later reclaim. The comments here assume the outpost refunds idempotently per (chain, token, reserve_code). If a tombstone refund can land after a reclaiming creator's escrow is already locked, that is a cross-chain desync — pre-existing on the unlinked-creator path, but this widens the trigger set. Worth confirming against the outpost side.
Three minor notes inline, none blocking.
Separately, I filed SEC-158 for the same unvalidated-payload-kind pattern in sysio.dclaim::onreward, where it is reachable through the staking-reward path and the consequence is a replay-cursor bypass rather than a stuck row. Out of scope for this PR.
Change-Id: Ia8da58c514a2bdf960b4ede5a00ef9f519286105
Change-Id: I6b6093c8a1794581d036eb2cb242677977162d63
heifner
left a comment
There was a problem hiding this comment.
Re-review of the current head (e7c911caa9). All three of my earlier comments are genuinely addressed — I verified each against the outposts rather than the replies alone.
Comment 1 (address vs. kind) — creator_address_matches_kind is correct against both live emitters: ETH ReserveManagerLib.sol:271 sends abi.encodePacked(sender) (20 bytes) with CHAIN_KIND_EVM; SOL reserve.rs:241 and init_liqsol_reserve.rs:307 send creator.to_bytes() (32 bytes) with ChainKindSvm. No regression for either.
Comment 2 (oncnclrsv) — resolved-kind comparison is in. One note below: it is not the no-op I implied when I called it cosmetic.
Comment 3 (refund unproven) — deploy_msgch() + the attestation-row assertion make it a real regression test; pre-fix the row is PENDING, so the status assertion already fails.
Threat model
Since the outposts are ours and honest, WNS-28 is not reachable through the normal create path at all — both outposts hardcode creator_addr.kind and the address shape, so a disagreeing payload would require forging at the OPP layer. That makes these guards defense-in-depth, which is the right call and consistent with the sysio.uwrit::swapfromwire precedent. It also reorders what actually matters below: the honest-outpost failure mode (a future chain kind) is the practically relevant one, not the adversarial one.
Artifact
Verified rather than assumed, since linux_amd64_build.yaml:267 sets SYSIO_BUILD_SYSTEM_CONTRACTS: OFF on non-tag refs — CI stages and tests the committed WASM. CI is green on e7c911caa9 across all five variants, so the committed artifact provably passes both new regression tests.
One stale item: the PR body's 1c6dd55b… is the b4f22e8415 artifact, superseded by the master merge (which changed reserv.cpp: WIRE_SYMBOL → opp::wire::asset_symbol). The head artifact is 7381b0f44d135b192b68c2ba79e77cd0962c201dc8f0d15bc98ec56a9a5fd325, and the "CI evidence pending" note is now out of date. Worth refreshing the body.
Separate issue, not this PR — the reclaim carve-out is unreachable
This closes the design question I left open. oncrtreserve's reclaimable_cancelled branch exists so a CANCELLED row does not permanently burn a (chain, token, reserve_code) identity. Neither outpost has a counterpart:
- ETH —
onReserveCreateCancelledsetsstatus = CANCELLEDand leavesrec.exists = true;_recordAndEmitReserveCreaterevertsWIRE_ReserveAlreadyExistswheneverexists. - SOL —
create_reserveuses Anchoriniton the Reserve PDA;handle_reserve_create_cancelledflips status and refunds but never closes it, and the only closer (admin_close_pending_reserve_handler) requiresstatus == Pending.
This does not depend on outpost honesty: all three ETH entries are external whenNotPaused OPPEnabled with no role gate, and SOL's own comment says create_reserve is permissionless. So any user can burn a reserve code permanently — create with an unlinked creator key, take the refund, leave the slot dead. The SOL admin escape hatch was built for the Pending griefing case and does not cover the post-cancel state.
Pre-existing, and this PR does not widen it in practice — the two new triggers cannot be produced by the honest outposts. Flagging it because this PR's comments (and my earlier review) both lean on reclaimability being real. Happy to file it separately.
Verdict
The fix is correct and complete for WNS-28, and the committed artifact carries it. The two inline notes are coverage gaps in the exact predicates this PR changed; neither is a correctness blocker.
| ("source_token_precision", 9u) | ||
| ("connector_weight_bps", 5000) | ||
| ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) | ||
| ("creator_chain_addr", creator_key) |
There was a problem hiding this comment.
This test no longer isolates the guard it was written for. creator_key is the 32-byte ed key, and ETH is registered CHAIN_KIND_EVM at line 94 — so invalid_creator_address fires here alongside creator_chain_kind_mismatch.
Deleting creator_chain_kind_mismatch || from the oncrtreserve predicate leaves all 711 cases green. The address check added for my earlier comment retroactively subsumed the WNS-28 regression test, so the PR's headline guard is now the one thing with no test that fails without it.
To isolate it: keep a valid 20-byte creator_chain_addr, set creator_chain_kind to CHAIN_KIND_SVM, and recordlink the 32-byte ed creator_pub_key under SVM against chain_code = ETH. That is PENDING pre-fix and CANCELLED post-fix, with the address check satisfied throughout.
There was a problem hiding this comment.
Correction: regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1) is at line 103 on this head, not 94 — stale local checkout on my end. The finding is unchanged: line 584 is the only CHAIN_KIND_SVM among the ten creator_chain_kind values in this file.
|
|
||
| const bool addr_matches = | ||
| it->creator_addr.kind == creator_chain_kind && | ||
| creator_chain_kind == *expected_chain_kind && |
There was a problem hiding this comment.
Correcting myself: I called this cosmetic, but creator_chain_kind == *expected_chain_kind is a genuinely new third conjunct — previously the payload kind was only compared against the stored kind, never against the registry. oncnclrsv has no test coverage anywhere in contracts/tests/ (no hit for oncnclrsv or ReserveCreateCancel), so this predicate is entirely unexercised.
No live risk — ETH cancel_create_reserve and both SOL emitters (cancel_create_reserve_handler, admin_close_pending_reserve_handler) hardcode the matching kind. But the failure mode is a silent skip on the refund path, which strands the creator's outpost escrow with no second chance, so it is worth one test.
| bool creator_address_matches_kind(opp::types::ChainKind kind, | ||
| const std::vector<char>& address) { | ||
| using opp::types::ChainKind; | ||
| if (kind == ChainKind::CHAIN_KIND_EVM) return address.size() == 20; |
There was a problem hiding this comment.
Given the outposts are ours and honest, this is the finding most likely to actually bite. Two ifs and a return false mean a future ChainKind — a new outpost doing everything correctly — silently routes every create on that chain into cancel+refund, with no compile-time signal and no error distinguishable from a malformed payload.
An exhaustive switch over ChainKind would let -Wswitch flag the new enumerator at build time, which is the posture the enums-are-first-class rule asks for. Same fail-closed behavior, just not silently.
Summary
sysio.chainsregistry insysio.reserv::oncrtreserveRESERVE_CREATE_CANCELLEDrefund pathoncnclrsvcompare both payload and stored creator kinds with the resolved registry kindsysio.reserv.wasmRoot cause
oncrtreservetrusted the redundantcreator_chain_kindsupplied by the inbound attestation. A payload could therefore name an EVM chain while selecting the SVM public-key variant. The resulting reserve remainedPENDING, butmatchreservelater used the registry-owned EVM kind and could never match the creator key.The follow-up review also identified that the creator address itself was not length-checked against that authoritative kind. The final implementation rejects both kind and address-shape mismatches through the same idempotent cancellation/refund path.
Validation
flow-reserve-lifecycle: SUCCESS, 46 phases / 157 steps in 911.9s, with the canonical heartbeat monitor and no fatal events1c6dd55b0e09e3b99f731d7f5be3f81b5ea762ec98423d1e1a6410ce910dc78fReviewer notes
The handler retains its never-throw dispatch semantics. Unregistered chains still soft-skip, while a registered-chain kind or address mismatch follows the idempotent cancellation/refund path used for malformed or unlinked creators. Invalid creator-address bytes are cleared before the cancellation tombstone is stored; the outpost refund remains keyed by the reserve triple.
Current-head CI and targeted remote flow evidence are pending for this pushed revision.